#!/usr/bin/env python
# coding: utf-8

# # 使用Tensorflow2、Milvus 、CNN在MovieLens数据集上构建个性化推荐系统
# 最近一段时间国内在深度学习、机器学习成果越来越多，开源了一批优秀工具和框架。9102年10月**国内** Zilliz 公司开源了Milvus，前不久已经正式加入 Linux AI （LF AI）基金会成为其最新的孵化项目。
# > LF AI 基金会的使命是建立和支持开放的人工智能社区，通过促进协作和创新为社区所有成员创造新机会，推动人工智能 (AI) 、机器学习 (ML) 和深度学习 (DL) 领域的开源创新。
# 
# ### 1、Milvus是干什么的？
# 
#   用官方的话：Milvus 是一款开源的特征向量相似度搜索引擎，具有使用方便、实用可靠、易于扩展、稳定高效和搜索迅速等特点，在全球范围内被上百家组织和机构所采用。我觉得它最大的三个亮点：
#     1. 向量搜索：支持各种常用的相似度计算指标，包括欧氏距离、内积、汉明距离和杰卡德距离等；
#     2. 高成本效益：Milvus 充分利用现代处理器的并行计算能力，可以在单台通用服务器上完成对十亿级数据的毫秒级搜索
#     3. 近实时搜索：在插入或更新数据之后，你可以几乎立刻对插入或更新过的数据进行搜索。Milvus 负责保证搜索结果的准确率和数据一致性
# 
# 当然不止这三点优点，更多特性请移步[Milvus官网](https://milvus.io/cn/docs/v0.9.1/about_milvus/overview.md)。
# 架构如下：<img src="assets/milvus_arch.png"/>
# 
# 用户：<img src="assets/milvus_user.jpg"/>
# ### 2、Milvus如何和个性化推荐系统结合使用呢？
#   简单介绍一下个性化推荐系统
# > 个性化推荐系统早已渗透进我们的生活了，随着大数据的发展，个性化推荐早已涉及诸多领域，比如电子商务（京东淘宝）、电影和电视网站（youtube）、个性化音乐网络电台（网易云音乐）、社交网络（QQ）、新闻阅读（今日头条）等。个性化推荐系统通过分析、挖掘用户行为，发现用户的个性化需求与兴趣特点，将用户可能感兴趣的信息或商品推荐给用户。
# 
# 推荐算法的本质是解决信息过载的问题，然后通过一种算法模型去猜测用户和物品可能存在的关联关系，最后去找到那些关联性比较强物品的推荐给用户。 具体的使用时候的关联方式，可以是用户关联用户；也可以是物品关联物品；还可以是用户关联物品，这也是常用的推荐几种方式。用户和物品的关联关系用计算机专业的术语的角度表述就是：相似性、距离，等等这个是不是就是Milvus的一个亮点？所以下面我们尝试讨论Milvus在movieslens数据集的使用。
# 

# ### 3、准备环境

# 1. 开发环境：Python3.7
# 2. 包安装环境：墙裂建议Anaconda3或者Miniconda3；当然你想用pip来安装也可以，尤记得几年前使用pip安装numpy、tensorflow的艰辛！
# 3. 工具：Tensorflow2.1、Milivus0.9.1、Jupyter、Pandas
# 4. 操作系统：Windows、Linux、Mac都行；最好有N卡的GPU，没有也没有关系， 只不过可能训练的时候会慢一些。
# 
# 开发环境的安装就不介绍了，自行Google，不要慌问题不大。
# 

# Milvus安装:
# 
# 推荐使用Docker 
# 
# 下面是Ubuntu/CentOS下CPU版本的使用
# 1. 拉取 Milvus 镜像 
# > docker pull milvusdb/milvus:0.10.0-cpu-d061620-5f3c00
# 
# 2. 下载配置文件
# > mkdir -p /home/USER/milvus/conf                   
# > cd /home/USER/milvus/conf                  
# > wget https://raw.githubusercontent.com/milvus-io/milvus/v0.10.0/core/conf/demo/server_config.yaml 
# 
# 2. 启动服务
# > docker run -d --name milvus_cpu_0.10.0 \
# -p 19530:19530 \
# -p 19121:19121 \
# -v /home/USER/milvus/db:/var/lib/milvus/db \
# -v /home/USER/milvus/conf:/var/lib/milvus/conf \
# -v /home/USER/milvus/logs:/var/lib/milvus/logs \
# -v /home/USER/milvus/wal:/var/lib/milvus/wal \
# milvusdb/milvus:0.10.0-cpu-d061620-5f3c00
# 
# 注意：Milvus Docker 可以支持Ubuntu、Windows、CentOS、macOS系统，但是对系统版本和硬件有要求， 据我测试好像不支持Debian。
# 
# 更多GPU安装、支持信息请请参考[Milvus官网安装](https://milvus.io/cn/docs/v0.10.0/guides/get_started/install_milvus/gpu_milvus_docker.md)

# ### 4、准备MovieLens数据集
# 兵马未动粮草先行。
# 
# 
# MovieLens数据集是一个关于电影评分的数据集，由 GroupLens Research 实验室搜集整理，里面包含了从IMDB, The Movie DataBase上面得到的用户对电影的评分信息。
# 官网地址: https://grouplens.org/datasets/movielens/ 。
# 
# 其中包含了几个不同数据量的版本，
# 我们以 MovieLens 百万数据集（ml-1m）为例进行介绍。ml-1m 数据集包含了 6,000 位用户对 4,000 部电影的 1,000,000 条评价（评分范围 1~5 分，均为整数）。下载地址：http://files.grouplens.org/datasets/movielens/ml-1m.zip  ，将其下载下来，并解压。

# ### 5、分析数据
# 
# 数据集分为三个文件：用户数据users.dat，电影数据movies.dat和评分数据ratings.dat。
# 
# 数据集的文件都是按行存储的，以“::”分割。
# 

# #### 5.1、 用户数据
# 分别有用户ID、性别、年龄、职业ID和邮编等字段。
# 
# 数据中的格式：UserID::Gender::Age::Occupation::Zip-code
# 
# - 性别是：
#    * M
#    * F
# - 年龄使用下列分布:
# 
# 	*  1:  "Under 18"
# 	* 18:  "18-24"
# 	* 25:  "25-34"
# 	* 35:  "35-44"
# 	* 45:  "45-49"
# 	* 50:  "50-55"
# 	* 56:  "56+"
# 
# - 职业是从下面几种选项里面选则得出::
# 
# 	*  0:  "other" or not specified
# 	*  1:  "academic/educator"
# 	*  2:  "artist"
# 	*  3:  "clerical/admin"
# 	*  4:  "college/grad student"
# 	*  5:  "customer service"
# 	*  6:  "doctor/health care"
# 	*  7:  "executive/managerial"
# 	*  8:  "farmer"
# 	*  9:  "homemaker"
# 	* 10:  "K-12 student"
# 	* 11:  "lawyer"
# 	* 12:  "programmer"
# 	* 13:  "retired"
# 	* 14:  "sales/marketing"
# 	* 15:  "scientist"
# 	* 16:  "self-employed"
# 	* 17:  "technician/engineer"
# 	* 18:  "tradesman/craftsman"
# 	* 19:  "unemployed"
# 	* 20:  "writer"
# 

# In[2]:


# 使用pandas预览
import pandas as pd
users_title = ['UserID', 'Gender', 'Age', 'OccupationID', 'Zip-code']
users = pd.read_csv('./ml-1m/users.dat', sep='::', header=None, names=users_title, engine = 'python')
users.head()


# In[3]:


# 看一下概况
users.describe()


# #### 5.2、电影数据
# 分别有电影ID、电影名和电影风格等字段。
# 
# 数据中的格式：MovieID::Title::Genres
# 
# - 标题与IMDB提供的标题相同（包括发布年份)
# - 所有的电影类型:
# 
# 	* Action
# 	* Adventure
# 	* Animation
# 	* Children's
# 	* Comedy
# 	* Crime
# 	* Documentary
# 	* Drama
# 	* Fantasy
# 	* Film-Noir
# 	* Horror
# 	* Musical
# 	* Mystery
# 	* Romance
# 	* Sci-Fi
# 	* Thriller
# 	* War
# 	* Western
# 

# In[4]:


movies_title = ['MovieID', 'Title', 'Genres']
movies = pd.read_csv('./ml-1m/movies.dat', sep='::', header=None, names=movies_title, engine = 'python')
movies.head()


# In[5]:


# 电影概况
movies.describe()


# In[6]:


title_words_max_len = movies['Title'].apply(lambda x : len(x.split())).max()
print(title_words_max_len)
# 电影类型最大的长度
genre_max_len = movies['Genres'].apply(lambda x : len(x.split('|'))).max()
print(genre_max_len)
# 最长电影标题
movies.iloc[movies['Title'].apply(lambda x : len(x.split())).idxmax()]['Title']


# In[7]:


# 电影最长的类型
movies.iloc[movies['Genres'].apply(lambda x : len(x.split('|'))).idxmax()]['Genres']


# #### 5.3、 评分数据
# 分别有用户ID、电影ID、评分和时间戳等字段。
# 
# 数据中的格式：UserID::MovieID::Rating::Timestamp
# 
# - UserIDs range between 1 and 6040 
# - MovieIDs range between 1 and 3952
# - Ratings are made on a 5-star scale (whole-star ratings only)
# - Timestamp is represented in seconds since the epoch as returned by time(2)
# - Each user has at least 20 ratings

# In[8]:


ratings_title = ['UserID','MovieID', 'Rating', 'timestamps']
ratings = pd.read_csv('./ml-1m/ratings.dat', sep='::', header=None, names=ratings_title, engine = 'python')
ratings.head()


# In[9]:


# 评分概况
ratings.describe()


# **评分字段Rating非常重要，表达了用户对电影的喜好程度，是一个衡量用户和电影关联关系程度重要指标。**

# ### 6. 数据清洗与处理
# 用户性别、电影标题、电影类别等需要向量化表示才能参与神经网络的计算，所以这里需要转换数字表示。
# 
# 用户：
# 
#    - Gender字段：需要将‘F’和‘M’转换成0和1。
#    - Age字段：要转成7个连续数字0~6。
#  
#  
# 电影：
#    - Genres字段：是分类字段，要转成数字。首先将Genres中的类别转成字符串到数字的字典，然后再将每个电影的Genres字段转成数字列表，因为有些电影是多个Genres的组合。
#    - Title字段：处理方式跟Genres字段一样，首先创建文本到数字的字典，然后将Title中的描述转成数字的列表。另外Title中的年份也需要去掉。
#    - Genres和Title字段需要将长度对齐，空白部分用‘< PAD >’对应的数字填充， 不然长短不一的tensor无法训练。
# 
# 评分：
#    - ratings字段： 归一化，这里简单的除以最高分5
#    - timestamps字段：暂时不使用
#    
# **下面是具体的处理代码：**

# In[10]:


import tensorflow as tf
import pandas as pd
from sklearn.model_selection import train_test_split
import numpy as np
from collections import Counter
import tensorflow as tf
import time
import os
import pickle
import re
from tensorflow.python.ops import math_ops
from tensorflow import keras


# In[11]:


def load_data():
    """
    使用pandas读取用户、电影、平等数据
    并进行数据处理
    """
    # 1、读取User数据
    users_title = ['UserID', 'Gender', 'Age', 'JobID', 'Zip-code']
    users = pd.read_csv('./ml-1m/users.dat', sep='::', header=None, names=users_title, engine='python')
    users = users.filter(regex='UserID|Gender|Age|JobID')
    users_orig = users.values
    # 性别类型转 0，1
    gender_map = {'F': 0, 'M': 1}
    users['Gender'] = users['Gender'].map(gender_map)

    # 年龄重新编码  TODO 为什么需要处理？
    age_map = {val: ii for ii, val in enumerate(set(users['Age']))}
    users['Age'] = users['Age'].map(age_map)

    # 2、 读取Movie数据集
    movies_title = ['MovieID', 'Title', 'Genres']
    movies = pd.read_csv('./ml-1m/movies.dat', sep='::', header=None, names=movies_title, engine='python')

    # 电影原始信息
    movies_orig = movies.values

    # 将Title中的年份去掉
    pattern = re.compile(r'^(.*)\((\d+)\)$')
    title_map = {val: pattern.match(val).group(1) for ii, val in enumerate(set(movies['Title']))}
    movies['Title'] = movies['Title'].map(title_map)

    # 电影分类的最多类型的长度，  分类需要按照这个对齐
    genre_max_len = movies['Genres'].apply(lambda x: len(x.split('|'))).max()
    # 电影类型转数字字典
    genres_set = set()
    for val in movies['Genres'].str.split('|'):
        genres_set.update(val)
    # 对齐单词
    genres_set.add('<PAD>')
    genres2int = {val: ii for ii, val in enumerate(genres_set)}

    # 将电影类型转成等长数字列表, 按照最长的类别数量进行对齐
    genres_map = {val: [genres2int[row] for row in val.split('|')] for ii, val in enumerate(set(movies['Genres']))}
    for key in genres_map:
        # 如果类别不够， 对齐
        for cnt in range(genre_max_len - len(genres_map[key])):
            genres_map[key].insert(len(genres_map[key]) + cnt, genres2int['<PAD>'])

    movies['Genres'] = movies['Genres'].map(genres_map)

    # 电影标题分词之后的最大长度， 标题需要按照最长的对齐
    title_words_max_len = movies['Title'].apply(lambda x: len(x.split())).max()
    # 电影Title转数字字典
    title_set = set()
    for val in movies['Title'].str.split():
        title_set.update(val)
    # 对齐单词
    title_set.add('<PAD>')
    title2int = {val: ii for ii, val in enumerate(title_set)}

    # 将电影Title转成等长数字列表，长度是15， 不够长度的需要对齐
    title_map = {val: [title2int[row] for row in val.split()] for ii, val in enumerate(set(movies['Title']))}
    for key in title_map:
        for cnt in range(title_words_max_len - len(title_map[key])):
            title_map[key].insert(len(title_map[key]) + cnt, title2int['<PAD>'])
    movies['Title'] = movies['Title'].map(title_map)

    # 3、 读取评分数据集
    ratings_title = ['UserID', 'MovieID', 'ratings', 'timestamps']
    ratings = pd.read_csv('./ml-1m/ratings.dat', sep='::', header=None, names=ratings_title, engine='python')
    ratings = ratings.filter(regex='UserID|MovieID|ratings')
    #ratings['ratings'] = ratings['ratings'].map(lambda x: x / 5)

    # 4、合并三个表
    data = pd.merge(pd.merge(ratings, users), movies)

    # 拆分为 用户电影关联 和 评分 两张表
    target_fields = ['ratings']
    features_pd, targets_pd = data.drop(target_fields, axis=1), data[target_fields]

    features = features_pd.values
    targets_values = targets_pd.values

    return genre_max_len, title_words_max_len, title_set, genres2int, features, targets_values, ratings, users, movies, data, movies_orig, users_orig


# #### 读取处理后的数据
# - title_words_max_len：Title字段的长度（15）
# - title_set：Title文本的集合
# - genres2int：电影类型转数字的字典
# - features：是输入X
# - targets_values：是学习目标y
# - ratings：评分数据集的Pandas对象
# - users：用户数据集的Pandas对象
# - movies：电影数据的Pandas对象
# - data：三个数据集组合在一起的Pandas对象
# - movies_orig：没有做数据处理的原始电影数据
# - users_orig：没有做数据处理的原始用户数据

# In[12]:


genre_max_len, title_words_max_len, title_set, genres2int, features, targets_values, ratings, users, movies, data, movies_orig, users_orig = load_data()


# #### 预览预处理后的数据

# In[13]:


# 预览用户
print(users.head())
# 电影
print(movies.head())
# 
print(ratings.head())


# In[14]:


movies['MovieID'].max()


# ### 7. 推荐模型设计
# 
# 在融合推荐模型的电影个性化推荐系统中：
# 
# 1. 首先，使用用户特征和电影特征作为神经网络的输入，其中：
# 用户特征融合了四个属性信息，分别是用户ID、性别、职业和年龄。
# 电影特征融合了三个属性信息，分别是电影ID、电影类型ID和电影名称。
# 2. 对用户特征，将用户ID映射为维度大小为256的向量表示，输入全连接层，并对其他三个属性也做类似的处理。然后将四个属性的特征表示分别全连接并相加。
# 3. 对电影特征，将电影ID以类似用户ID的方式进行处理，电影类型ID以向量的形式直接输入全连接层，电影名称用文本卷积神经网络得到其定长向量表示。然后将三个属性的特征表示分别全连接并相加。
# 4. 得到用户和电影的向量表示后，计算二者的余弦相似度作为个性化推荐系统的打分。最后，用该相似度打分和用户真实打分的差异的平方作为该回归模型的损失函数。
# 
# <img src="assets/model.jpeg"/>

# 通过研究数据集中的字段类型，我们发现有一些是类别字段，通常的处理是将这些字段转成one hot编码，但是像UserID、MovieID这样的字段就会变成非常的稀疏，输入的维度急剧膨胀，这是我们不愿意见到的，毕竟我这小笔记本不像大厂动辄能处理数以亿计维度的输入：）
# 
# 所以在预处理数据时将这些字段转成了数字，我们用这个数字当做嵌入矩阵的索引，在网络的第一层使用了嵌入层，维度是（N，32）和（N，16）。
# 
# 电影类型的处理要多一步，有时一个电影有多个电影类型，这样从嵌入矩阵索引出来是一个（n，32）的矩阵，因为有多个类型嘛，我们要将这个矩阵求和，变成（1，32）的向量。
# 
# 电影名的处理比较特殊，没有使用循环神经网络，而是用了文本卷积网络，下文会进行说明。
# 
# 从嵌入层索引出特征以后，将各特征传入全连接层，将输出再次传入全连接层，最终分别得到（1，200）的用户特征和电影特征两个特征向量。
# 
# 我们的目的就是要训练出用户特征和电影特征，在实现推荐功能时使用。得到这两个特征以后，就可以选择任意的方式来拟合评分了。我使用了两种方式，一个是上图中画出的将两个特征做向量乘法，将结果与真实评分做回归，采用MSE优化损失。因为本质上这是一个回归问题，另一种方式是，将两个特征作为输入，再次传入全连接层，输出一个值，将输出值回归到真实评分，采用MSE优化损失。
# 
# 实际上第二个方式的MSE loss在0.8附近，第一个方式在1附近，5次迭代的结果。

# ## 文本卷积网络
# 网络看起来像下面这样

# <img src="assets/text_cnn.png"/>
# 图片来自Kim Yoon的论文：[`Convolutional Neural Networks for Sentence Classification`](https://arxiv.org/abs/1408.5882)
# 
# 将卷积神经网络用于文本的文章建议你阅读[`Understanding Convolutional Neural Networks for NLP`](http://www.wildml.com/2015/11/understanding-convolutional-neural-networks-for-nlp/)

# 网络的第一层是词嵌入层，由每一个单词的嵌入向量组成的嵌入矩阵。下一层使用多个不同尺寸（窗口大小）的卷积核在嵌入矩阵上做卷积，窗口大小指的是每次卷积覆盖几个单词。这里跟对图像做卷积不太一样，图像的卷积通常用2x2、3x3、5x5之类的尺寸，而文本卷积要覆盖整个单词的嵌入向量，所以尺寸是（单词数，向量维度），比如每次滑动3个，4个或者5个单词。第三层网络是max pooling得到一个长向量，最后使用dropout做正则化，最终得到了电影Title的特征。

# ### 8. 编码实现

# #### 8.1 网络参数

# In[15]:


# 嵌入矩阵的维度
embed_dim = 32
# 用户ID个数
uid_max = max(features.take(0, 1)) + 1  # 6040
# 性别个数
gender_max = max(features.take(2, 1)) + 1  # 1 + 1 = 2
# 年龄类别个数
age_max = max(features.take(3, 1)) + 1  # 6 + 1 = 7
# 职业个数
job_max = max(features.take(4, 1)) + 1  # 20 + 1 = 21

# 电影ID个数
movie_id_max = max(features.take(1, 1)) + 1  # 3952
# 电影类型个数
movie_categories_max = max(genres2int.values()) + 1  # 18 + 1 = 19
# 电影名单词个数
movie_title_max = len(title_set)  # 5216
# 电影类型长度 
genre_size = genre_max_len # 6

# 电影名长度
sentences_size = title_words_max_len  # = 15
# 文本卷积滑动窗口，分别滑动2, 3, 4, 5个单词
window_sizes = {2, 3, 4, 5}
# 文本卷积核数量
filter_num = 8

# 分析数据， 由于电影的IP不是连续的，所以需要转换处理
# 电影ID转下标的字典，数据集中电影ID跟下标不一致，比如第5行的数据电影ID不一定是5
# key 电影id， value是行索引
movieid2idx = {val[0]: i for i, val in enumerate(movies.values)}

MODEL_DIR = "./models"

# 保存路径
save_dir = './save'


# #### 8.2 网络超参

# In[16]:


# Number of Epochs
num_epochs = 5
# 批量大小
batch_size = 256

dropout_keep = 0.5
# 学习率
learning_rate = 0.0001


# #### 8.3 定义输入的占位符

# In[17]:


def get_inputs():
    """
    获取用户和电影的 input特征
    :return:  uid, user_gender, user_age, user_job, movie_id, movie_categories, movie_titles
    """
    uid = tf.keras.layers.Input(shape=(1,), dtype='int32', name='uid')
    user_gender = tf.keras.layers.Input(shape=(1,), dtype='int32', name='user_gender')
    user_age = tf.keras.layers.Input(shape=(1,), dtype='int32', name='user_age')
    user_job = tf.keras.layers.Input(shape=(1,), dtype='int32', name='user_job')

    movie_id = tf.keras.layers.Input(shape=(1,), dtype='int32', name='movie_id')
    movie_categories = tf.keras.layers.Input(shape=(6,), dtype='int32', name='movie_categories')
    movie_titles = tf.keras.layers.Input(shape=(15,), dtype='int32', name='movie_titles')
    return uid, user_gender, user_age, user_job, movie_id, movie_categories, movie_titles


# ### 8.4. 构建用户和电影的嵌入特征

# #### 8.4.1 User的嵌入矩阵
# 定义User的嵌入矩阵

# In[18]:


def get_user_embedding(uid, user_gender, user_age, user_job):
    """
        获取用户的embedding的特征
    :param uid:
    :param user_gender:
    :param user_age:
    :param user_job:
    :return:
    """

    # vocab_size:字典大小
    # embedding_dim:本层的输出大小，也就是生成的embedding的维数
    # input_length:输入数据的维数，因为输入数据会做padding处理，所以一般是定义的max_length
    uid_embed_layer = tf.keras.layers.Embedding(uid_max, embed_dim, input_length=1, name='uid_embed_layer')(uid)
    gender_embed_layer = tf.keras.layers.Embedding(gender_max, embed_dim // 2, input_length=1, name='gender_embed_layer')(user_gender)
    age_embed_layer = tf.keras.layers.Embedding(age_max, embed_dim // 2, input_length=1, name='age_embed_layer')(user_age)
    job_embed_layer = tf.keras.layers.Embedding(job_max, embed_dim // 2, input_length=1, name='job_embed_layer')(user_job)
    return uid_embed_layer, gender_embed_layer, age_embed_layer, job_embed_layer


# **将上一步的User的嵌入矩阵一起全连接生成User的特征**

# In[19]:


def get_user_feature_layer(uid_embed_layer, gender_embed_layer, age_embed_layer, job_embed_layer):
    """
    用户处理后的特征，加上全连接
    :param uid_embed_layer:
    :param gender_embed_layer:
    :param age_embed_layer:
    :param job_embed_layer:
    :return:
    """
    # 第一层全连接
    uid_fc_layer = tf.keras.layers.Dense(embed_dim, name="uid_fc_layer", activation='relu')(uid_embed_layer)
    gender_fc_layer = tf.keras.layers.Dense(embed_dim, name="gender_fc_layer", activation='relu')(gender_embed_layer)
    age_fc_layer = tf.keras.layers.Dense(embed_dim, name="age_fc_layer", activation='relu')(age_embed_layer)
    job_fc_layer = tf.keras.layers.Dense(embed_dim, name="job_fc_layer", activation='relu')(job_embed_layer)

    # 第二层全连接
    user_combine_layer = tf.keras.layers.concatenate([uid_fc_layer, gender_fc_layer, age_fc_layer, job_fc_layer], 2)  # (?, 1, 128)
    user_combine_layer = tf.keras.layers.Dense(200, activation='tanh')(user_combine_layer)  # (?, 1, 200)

    user_combine_layer_flat = tf.keras.layers.Reshape([200], name="user_combine_layer_flat")(user_combine_layer)
    return user_combine_layer, user_combine_layer_flat


# #### 8.4.2  Movie的嵌入矩阵
# 定义Movie ID的嵌入矩阵

# In[20]:


def get_movie_id_embed_layer(movie_id):
    movie_id_embed_layer = tf.keras.layers.Embedding(movie_id_max, embed_dim, input_length=1, name='movie_id_embed_layer')(movie_id)
    return movie_id_embed_layer


# #### 合并电影类型的多个嵌入向量

# In[21]:


def get_movie_categories_layers(movie_categories):
    movie_categories_embed_layer = tf.keras.layers.Embedding(movie_categories_max, embed_dim, input_length=6, name='movie_categories_embed_layer')(movie_categories)
    movie_categories_embed_layer = tf.keras.layers.Lambda(lambda layer: tf.reduce_sum(layer, axis=1, keepdims=True))(movie_categories_embed_layer)
    #     movie_categories_embed_layer = tf.keras.layers.Reshape([1, 6 * embed_dim])(movie_categories_embed_layer)

    return movie_categories_embed_layer


# #### Movie Title的文本卷积网络实现

# In[22]:


def get_movie_cnn_layer(movie_titles):
    """电影title的embedding编码 然后使用 CNN"""
    # 从嵌入矩阵中得到电影名对应的各个单词的嵌入向量
    movie_title_embed_layer = tf.keras.layers.Embedding(movie_title_max, embed_dim, input_length=15, name='movie_title_embed_layer')(movie_titles)
    sp = movie_title_embed_layer.shape
    movie_title_embed_layer_expand = tf.keras.layers.Reshape([sp[1], sp[2], 1])(movie_title_embed_layer)
    # 对文本嵌入层使用不同尺寸的卷积核做卷积和最大池化
    pool_layer_lst = []
    for window_size in window_sizes:
        conv_layer = tf.keras.layers.Conv2D(filter_num, (window_size, embed_dim), 1, activation='relu')(movie_title_embed_layer_expand)
        maxpool_layer = tf.keras.layers.MaxPooling2D(pool_size=(sentences_size - window_size + 1, 1), strides=1)(conv_layer)
        pool_layer_lst.append(maxpool_layer)
    # Dropout层
    pool_layer = tf.keras.layers.concatenate(pool_layer_lst, 3, name="pool_layer")
    max_num = len(window_sizes) * filter_num
    pool_layer_flat = tf.keras.layers.Reshape([1, max_num], name="pool_layer_flat")(pool_layer)

    dropout_layer = tf.keras.layers.Dropout(dropout_keep, name="dropout_layer")(pool_layer_flat)
    return pool_layer_flat, dropout_layer


# #### 将Movie的各个层一起做全连接

# In[23]:


def get_movie_feature_layer(movie_id_embed_layer, movie_categories_embed_layer, dropout_layer):
    """
    电影的全连接特征处理
    :param movie_id_embed_layer:
    :param movie_categories_embed_layer:
    :param dropout_layer:
    :return:
    """
    # 第一层全连接
    movie_id_fc_layer = tf.keras.layers.Dense(embed_dim, name="movie_id_fc_layer", activation='relu')(movie_id_embed_layer)
    movie_categories_fc_layer = tf.keras.layers.Dense(embed_dim, name="movie_categories_fc_layer", activation='relu')(movie_categories_embed_layer)

    # 第二层全连接
    movie_combine_layer = tf.keras.layers.concatenate([movie_id_fc_layer, movie_categories_fc_layer, dropout_layer], 2)
    movie_combine_layer = tf.keras.layers.Dense(200, activation='tanh')(movie_combine_layer)

    movie_combine_layer_flat = tf.keras.layers.Reshape([200], name="movie_combine_layer_flat")(movie_combine_layer)
    return movie_combine_layer, movie_combine_layer_flat


# ### 8.5 构建计算图

# **通过生成模型，迭代获取数据**

# In[24]:


def get_batches(x_data, y_list, batch_size):
    """yield取得batch"""
    for start in range(0, len(x_data), batch_size):
        end = min(start + batch_size, len(x_data))
        yield x_data[start:end], y_list[start:end]


# In[38]:



class MovieRecommendNN(object):
    """
    电影推荐网络
    """

    def __init__(self, batch_size=256):
        self.batch_size = batch_size
        self.best_loss = 9999
        # 损失
        self.losses = {'train': [], 'test': []}

        # 1.1、获取输入占位符
        uid, user_gender, user_age, user_job, movie_id, movie_categories, movie_titles = get_inputs()
        # 1.2、获取User的4个嵌入向量
        uid_embed_layer, gender_embed_layer, age_embed_layer, job_embed_layer = get_user_embedding(uid, user_gender, user_age, user_job)
        # 1.3、拼接上面4个特征，然后加上全连接， 得到用户特征
        user_combine_layer, user_combine_layer_flat = get_user_feature_layer(uid_embed_layer, gender_embed_layer, age_embed_layer, job_embed_layer)

        # 2.1、获取电影ID的嵌入向量
        movie_id_embed_layer = get_movie_id_embed_layer(movie_id)
        # 2.2、获取电影类型的嵌入向量
        movie_categories_embed_layer = get_movie_categories_layers(movie_categories)
        # 2.3、使用embedding和CNN, 获取电影名的特征向量
        pool_layer_flat, dropout_layer = get_movie_cnn_layer(movie_titles)
        # 2.4、拼接上面三个特征， 然后加上全连接， 得到电影特征
        movie_combine_layer, movie_combine_layer_flat = get_movie_feature_layer(movie_id_embed_layer,
                                                                                movie_categories_embed_layer,
                                                                                dropout_layer)
        # 计算出评分
        # 将用户特征和电影特征做矩阵乘法得到一个预测评分的方案
        inference = tf.keras.layers.Lambda(lambda layer:
                                           tf.reduce_sum(layer[0] * layer[1], axis=1), name="inference")((user_combine_layer_flat, movie_combine_layer_flat))

        # 拓展纬度
        inference = tf.keras.layers.Lambda(lambda layer: tf.expand_dims(layer, axis=1))(inference)

        # 定义模型， 指定输入和输出
        self.model = tf.keras.Model(
            inputs=[uid, user_gender, user_age, user_job, movie_id, movie_categories, movie_titles],
            outputs=[inference])
        # 模型的结构
        self.model.summary()

        # 优化器， 学习率
        self.optimizer = tf.keras.optimizers.Adam(learning_rate)
        # MSE损失，将计算值回归到评分, 计算标签和预测之间的误差平方的平均值
        self.computeLoss = tf.keras.losses.MeanSquaredError()
        # 计算标签和预测之间的平均绝对误差
        self.computeMetrics = tf.keras.metrics.MeanAbsoluteError()

        if not tf.io.gfile.exists(MODEL_DIR):
            tf.io.gfile.makedirs(MODEL_DIR)

        checkpoint_dir = os.path.join(MODEL_DIR, 'checkpoints')
        self.checkpoint_prefix = os.path.join(checkpoint_dir, 'ckpt')
        self.checkpoint = tf.train.Checkpoint(model=self.model, optimizer=self.optimizer)

        # Restore variables on creation if a checkpoint exists.
        self.checkpoint.restore(tf.train.latest_checkpoint(checkpoint_dir))

    @tf.function
    def train_step(self, x, y):
        """
        训练
        :param x:
        :param y:
        :return:
        """
        # Record the operations used to compute the loss, so that the gradient
        # of the loss with respect to the variables can be computed.
        #         metrics = 0
        with tf.GradientTape() as tape:
            logits = self.model([x[0],
                                 x[1],
                                 x[2],
                                 x[3],
                                 x[4],
                                 x[5],
                                 x[6]], training=True)
            loss = self.computeLoss(y, logits)
            self.computeMetrics(y, logits)

        # 求梯度
        grads = tape.gradient(loss, self.model.trainable_variables)
        # 优化算法
        self.optimizer.apply_gradients(zip(grads, self.model.trainable_variables))
        return loss, logits

    def training(self, features, targets_values, epochs=5, log_freq=50):
        """epoch 训练"""
        for epoch_i in range(epochs):
            # 将数据集分成训练集和测试集，随机种子不固定
            train_X, test_X, train_y, test_y = train_test_split(features, targets_values, test_size=0.2, random_state=0)

            # 获取训练的批量数据
            train_batches = get_batches(train_X, train_y, self.batch_size)
            # 批量的次数
            batch_num = (len(train_X) // self.batch_size)

            train_start = time.time()

            if True:
                # 遍历一遍训练数据
                for batch_i in range(batch_num):
                    start = time.time()
                    # 训练数据
                    x_list, y_list = next(train_batches)

                    # 训练
                    loss, logits = self.train_step([np.reshape(x_list.take(0, 1), [self.batch_size, 1]).astype(np.float32),
                                                    np.reshape(x_list.take(2, 1), [self.batch_size, 1]).astype(np.float32),
                                                    np.reshape(x_list.take(3, 1), [self.batch_size, 1]).astype(np.float32),
                                                    np.reshape(x_list.take(4, 1), [self.batch_size, 1]).astype(np.float32),
                                                    np.reshape(x_list.take(1, 1), [self.batch_size, 1]).astype(np.float32),
                                                    np.reshape(np.stack(x_list.take(6, 1), axis=0), [self.batch_size, genre_size]).astype(np.float32),
                                                    np.reshape(np.stack(x_list.take(5, 1), axis=0), [self.batch_size, sentences_size]).astype(np.float32)],
                                                    np.reshape(y_list, [self.batch_size, 1]).astype(np.float32))
                    # 平均损失
                    # avg_loss(loss)
                    self.losses['train'].append(loss)

                    # 打印损失
                    rate = batch_num / (time.time() - start)
                    print('Step #{}\tEpoch {:>3} Batch {:>4}/{}   Loss: {:0.6f} mae: {:0.6f} ({} steps/sec)'.format(
                        self.optimizer.iterations.numpy(),
                        epoch_i,
                        batch_i,
                        batch_num,
                        loss, (self.computeMetrics.result()), rate))
                    # avg_loss.reset_states()
                    self.computeMetrics.reset_states()
                    start = time.time()

                    train_end = time.time()
                    print('\nTrain time for epoch #{} ({} total steps): {}'.format(epoch_i, self.optimizer.iterations.numpy(), train_end - train_start))

                # 训练一轮之后 测试
                self.testing((test_X, test_y), self.optimizer.iterations)

            self.export_path = os.path.join(MODEL_DIR, 'export')
            # 训练完成之后保存模型到硬盘， 需要消耗时间
            tf.saved_model.save(self.model, self.export_path)

    def testing(self, test_dataset, step_num):
        """ 测试 """
        test_X, test_y = test_dataset
        test_batches = get_batches(test_X, test_y, self.batch_size)

        avg_loss = tf.keras.metrics.Mean('loss', dtype=tf.float32)

        batch_num = (len(test_X) // self.batch_size)
        for batch_i in range(batch_num):
            x_list, y_list = next(test_batches)

            # 训练 输入训练数据， 获取损失和
            logits = self.model([np.reshape(x_list.take(0, 1), [self.batch_size, 1]).astype(np.float32),
                                            np.reshape(x_list.take(2, 1), [self.batch_size, 1]).astype(np.float32),
                                            np.reshape(x_list.take(3, 1), [self.batch_size, 1]).astype(np.float32),
                                            np.reshape(x_list.take(4, 1), [self.batch_size, 1]).astype(np.float32),
                                            np.reshape(x_list.take(1, 1), [self.batch_size, 1]).astype(np.float32),
                                            np.reshape(np.stack(x_list.take(6, 1), axis=0), [self.batch_size, genre_size]).astype(np.float32),
                                            np.reshape(np.stack(x_list.take(5, 1), axis=0), [self.batch_size, sentences_size]).astype(np.float32)],
                                            training=False)

            test_loss = self.computeLoss(np.reshape(y_list, [self.batch_size, 1]).astype(np.float32), logits)
            avg_loss(test_loss)
            # 保存测试损失
            self.losses['test'].append(test_loss)
            self.computeMetrics(np.reshape(y_list, [self.batch_size, 1]).astype(np.float32), logits)

        print('Model test set loss: {:0.6f} mae: {:0.6f}'.format(avg_loss.result(), self.computeMetrics.result()))

        # 最好的训练测试结果保存
        if avg_loss.result() < self.best_loss:
            self.best_loss = avg_loss.result()
            print("best loss = {}".format(self.best_loss))
            self.checkpoint.save(self.checkpoint_prefix)


# **训练和测试**

# In[26]:


# 将用户特征和电影特征做矩阵乘法得到一个预测评分的训练
mv_net = MovieRecommendNN()
mv_net.training(features, targets_values, epochs=5)


# #### 训练Loss

# In[27]:


get_ipython().run_line_magic('matplotlib', 'inline')
get_ipython().run_line_magic('config', "InlineBackend.figure_format = 'retina'")
import matplotlib.pyplot as plt

plt.plot(mv_net.losses['train'], label='Training loss')
plt.legend()
_ = plt.ylim()


# #### 测试Loss

# In[28]:


plt.plot(mv_net.losses['test'], label='Test loss')
plt.legend()
_ = plt.ylim()


# ### 8.6 生成电影的最终特征

# In[29]:


movie_layer_model = keras.models.Model(inputs=[mv_net.model.input[4], mv_net.model.input[5], mv_net.model.input[6]],
                                       outputs=mv_net.model.get_layer("movie_combine_layer_flat").output)
movie_matrics = []

# 遍历所有的电影
for item in movies.values:
    categories = np.zeros([1, genre_size])
    categories[0] = item.take(2)

    titles = np.zeros([1, sentences_size])
    titles[0] = item.take(1)

    movie_combine_layer_flat_val = movie_layer_model([np.reshape(item.take(0), [1, 1]), categories, titles])
    movie_matrics.append(movie_combine_layer_flat_val)

# 保存电影的特征到文件
pickle.dump((np.array(movie_matrics).reshape(-1, 200)), open('movie_matrics.p', 'wb'))
movie_matrics = pickle.load(open('movie_matrics.p', mode='rb'))


# ### 8.7 生成用户的最终特征

# In[30]:


# 将训练好的用户特征组合成用户特征矩阵并保存到本地
user_layer_model = keras.models.Model(inputs=[mv_net.model.input[0], mv_net.model.input[1], mv_net.model.input[2], mv_net.model.input[3]],
                                      outputs=mv_net.model.get_layer("user_combine_layer_flat").output)
users_matrics = []

# 遍历获取用户的特征
for item in users.values:
    user_combine_layer_flat_val = user_layer_model([np.reshape(item.take(0), [1, 1]),
                                                    np.reshape(item.take(1), [1, 1]),
                                                    np.reshape(item.take(2), [1, 1]),
                                                    np.reshape(item.take(3), [1, 1])])
    users_matrics.append(user_combine_layer_flat_val)

# 保存用户特征到文件  TODO
pickle.dump((np.array(users_matrics).reshape(-1, 200)), open('users_matrics.p', 'wb'))
users_matrics = pickle.load(open('users_matrics.p', mode='rb'))


# ### 9. 使用TF推荐

# #### 根据用户的id， 获取topk个推荐的电影
# 思路是使用用户特征向量与电影特征矩阵计算所有电影的评分，取评分最高的top_k个
# 
# 注意： **在获取到topk个里面，可能的有客户已经看过的， 所以需要过滤掉已经看过的， 这里暂时不处理了。**

# In[31]:


def recommend_topk_movie_by_user_id_val(user_id_val, top_k=10):
    """
    根据用户推荐电影
    """
    # 推荐您喜欢的电影, 用户特征
    user_embedding = tf.reshape(users_matrics[user_id_val - 1], [1, 200])

    # tf.transpose  转置
    probs_similarity = tf.matmul(user_embedding, tf.transpose(movie_matrics))
    sim = probs_similarity.numpy().flatten()

    print("以下是给您的推荐：")
    # np.squeeze 从数组的形状中删除单维度条目，即把shape中为1的维度去掉
    # 变成一个维度
    # argsort()函数是将x中的元素从小到大排列，提取其对应的index(索引)，然后输出到y
    idx_re_movies = np.argsort(-sim)[0:top_k]
    results = []
    for im in (idx_re_movies):
        results.append(movies_orig[im])
    return results


# **推荐电影**

# In[32]:


start = time.time()
results = recommend_topk_movie_by_user_id_val(186, 10)
print(results)
print("使用TF推荐耗时：{}".format(time.time() - start))


# ### 10. 使用Milvus进行推荐
# 
# 与上面使用TF进行推荐的思路“思路是使用用户特征向量与电影特征矩阵计算所有电影的评分，取评分最高的top_k个”类似，我们可以把用户的特征向量作为Milvus的查询向量，查询与用户特征向量最相关（**距离最近的**）的top_k个向量，作为推荐给用户的电影。
# 
# #### 10.1 归一化特征向量

# In[33]:


from milvus import Milvus, MetricType
from functools import reduce

mov_table_name = 'mov_embedding'
# 系数
L2_SQUARE = 0


def normaliz_data(vec_list):
    """标准化"""
    for i in range(len(vec_list)):
        vec = vec_list[i]
        # 如果没有初始化, 则计算
        global L2_SQUARE
        if L2_SQUARE == 0:
            # 平方和
            square_sum = reduce(lambda x, y: x + y, map(lambda x: x * x, vec))
            sqrt_square_sum = np.sqrt(square_sum)
            L2_SQUARE = 1 / sqrt_square_sum
        vec = vec_list[i]
        vec = list(map(lambda x: x * L2_SQUARE, vec))
        # 更新
        vec_list[i] = vec
    return vec_list


# #### 10.2 插入全部向量至Milvus

# In[34]:


def save2milvus(collection_name, features, ids):
    """保存电影向量至数据库"""
    _HOST = '192.168.0.10'
    _PORT = '19530'  # default value
    milvus = Milvus(host=_HOST, port=_PORT)


    status, ok = milvus.has_collection(collection_name)
    print(ok)
    if ok:
        status = milvus.drop_collection(collection_name)
        time.sleep(3)

    if features is None:
        print("Insert vectors is none!")
    param = {
        'collection_name': collection_name,
        'dimension': 200,
        'index_file_size': 1024,  # optional
        'metric_type': MetricType.IP  # optional
    }

    print(milvus.create_collection(param))

    insert_vectors = normaliz_data(features)
    print(len(insert_vectors))
    status, ids = milvus.insert(collection_name=collection_name, records=insert_vectors, ids=ids)
    time.sleep(1)

    status, result = milvus.count_entities(collection_name)
    print("rows in table count:", result)


# In[35]:


save2milvus(mov_table_name, features=movie_matrics, ids=list(range(1, len(movie_matrics) + 1)))


# #### 10.2 根据用户特征查询最相关的电影

# In[36]:


def search_movies(collection_name, features, top_k=5):
    search_vectors = normaliz_data(features)
    #print(search_vectors)
    param = {
        'collection_name': collection_name,
        'query_records': search_vectors,
        'top_k': top_k,
        'params': {'nprobe': 16}
    }
    _HOST = '192.168.0.10'
    _PORT = '19530'  # default value
    milvus = Milvus(host=_HOST, port=_PORT)
    time1 = time.time()
    status, results = milvus.search(**param)
    time2 = time.time()

    print("Ids\t", "Title\t", "Genres")
    mv_ids = []
    for i, re in enumerate(results[0]):
        #print(i, "\t", re.id)
        mv_ids.append(re.id)

    return mv_ids


# In[37]:


start = time.time()
recommend_movie_idxs = search_movies(mov_table_name, [users_matrics[186 - 1]], 10)
# 找电影id
for i in recommend_movie_idxs:
    print(movies_orig[i - 1])
print("使用Milvus推荐耗时：{}".format(time.time() - start))


# 通过推荐结果对比， 我们发现使用Tensorflow和使用Milvus生成的结果是一样的。那么至少说明可以在这周场景下，说明Milvus的查询结果的可靠性

# ### 11. 总结
# 
# 以上是我个人对推荐系统的理解以及Milvus在推荐系统场景下结合神经网络的简单使用。
# 
# 传统关系型数据库针对字母数字类数据而设计，把不同数据整理归纳为相互关联的集合；Milvus作为区别于传统数据库的新的向量数据库，可以在海量数据、高维特征下，提供向量搜索。随着大数据、深度学习的兴起，我们构建的系统越来越复杂，Milvus的出现为我们在构建系统架构时，多了一种选择；结合各种深度学习平台，Milvus 等待着探索更多 AI 用法。
# 
# Milvus的应用场景还包括：图像视频检索、智能问答机器人、向量计算、音频数据处理等。 比如可以和BERT（Bidirectional Encoder Representation from Transformers）结合使用开发一个智能问答系统，通过BERT得到特征向量，然后再做向量搜索，得到问题答案。
# 
# **Milvus**作为国内开源的数据库，开源没多久就在国内这么多大厂里应用，也充分体现了它的欢迎程度和价值。
# 
# 我个人挺看好Milvus的未来，是国内开源的有行业领先水平。随着中美“科技战”，暴露了我们很多东西受制于美；我们需要肩负自己的那一份责任，希望国内越来越多像Milvus这样的产品的开发并开源出来，造福全国人民、造福世界人民！

# ### 参考
# 1. http://files.grouplens.org/datasets/movielens/ml-1m.zip
# 2. http://files.grouplens.org/datasets/movielens/ml-1m-README.txt
# 3. https://milvus.io/cn/
# 4. https://zhuanlan.zhihu.com/p/32078473
# 5. https://www.paddlepaddle.org.cn/documentation/docs/zh/user_guides/rec_case/recommender_system/README.cn.html
